import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
import {
Users, Truck, Zap, Cpu, ShieldCheck, Boxes, Briefcase, ClipboardList,
Plus, Minus, Camera, Image as ImageIcon, ScanLine, X, ChevronRight, ChevronLeft,
ChevronDown, Phone, MapPin, CalendarDays, Settings as SettingsIcon, Search,
Package, Wrench, AlertTriangle, Check, CloudOff, RefreshCw, Star, History,
Globe, LogOut, Building2, User as UserIcon, Trash2, ArrowRightLeft, Info,
FileText, ClipboardCheck, Layers
} from "lucide-react";
/* ============================================================================
DESIGN TOKENS
A tool-crib / site-panel aesthetic: steel-gray field, near-black ink,
safety-yellow primary action, three distinct action colors (taken / returned
/ repair), and a dark "gauge readout" treatment for stock numbers —
evoking the digital counter on a crib scale rather than a generic app.
============================================================================ */
const TOKENS = {
bg: "#E4E7E9",
bgPanel: "#FFFFFF",
ink: "#14181C",
inkSoft: "#4B5560",
line: "#C6CCD1",
lineStrong: "#14181C",
yellow: "#FFC02E",
yellowInk: "#14181C",
blue: "#2A5EDB",
blueSoft: "#E8EEFC",
taken: "#C13B2C",
takenSoft: "#FBE7E3",
returned: "#1E8A57",
returnedSoft: "#E4F5EC",
repair: "#D9820B",
repairSoft: "#FCEEDA",
gaugeBg: "#171B1E",
gaugeText: "#7CF29C",
danger: "#C13B2C",
radius: "10px",
radiusSm: "6px",
};
const FONT_IMPORT = `@import url('https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@600;700;800&family=Barlow:wght@400;500;600;700&family=JetBrains+Mono:wght@600;700&display=swap');`;
/* ============================================================================
TRANSLATIONS
============================================================================ */
const TRANSLATIONS = {
he: {
dir: "rtl",
appName: "מלאי אתר",
login_title: "כניסה למערכת",
login_username: "שם משתמש",
login_role: "תפקיד",
login_project: "בחירת פרויקט",
login_selectProject: "בחר פרויקט…",
login_addProject: "הוספת פרויקט חדש",
login_language: "שפה",
login_enter: "כניסה",
login_needFields: "יש למלא שם משתמש, תפקיד ולבחור פרויקט",
addProject_title: "פרויקט חדש",
addProject_name: "שם האתר / פרויקט",
addProject_address: "כתובת ומיקום",
addProject_contact: "אחראי לוגיסטי",
addProject_phone: "טלפון ליצירת קשר",
save: "שמירה",
cancel: "ביטול",
siteDetails_title: "פרטי אתר",
menu_calendar: "לוח שנה",
menu_summary: "סיכום מלאי",
menu_settings: "הגדרות",
menu_addCategory: "הוספת קטגוריה",
addCategory_title: "קטגוריה חדשה",
addCategory_name: "שם הקטגוריה",
addCategory_icon: "בחר סמל",
category_addItem: "הוספת ציוד",
category_empty: "אין עדיין פריטים בקטגוריה הזו. הוסיפו את הראשון.",
addItem_title: "ציוד חדש",
addItem_name: "שם הפריט / הכלי",
addItem_threshold: "סף התראת מלאי נמוך (לא חובה)",
item_found: "מלאי נמצא",
item_ordered: "מוזמן",
item_framework: "מסגרת",
item_order: "הוזמן",
item_frameworkOrder: "הזמנת מסגרת +",
item_taken: "נלקח",
item_returned: "הוחזר",
item_repair: "נשלח לתיקון",
item_received: "התקבלה סחורה",
item_inRepair: "בתיקון",
item_returnFromRepair: "חזר מתיקון",
item_lowStock: "מלאי נמוך",
item_history: "היסטוריית פעולות",
item_noHistory: "אין עדיין פעולות מתועדות לפריט זה.",
item_favorite: "מועדף",
order_title: "פרטי הזמנה",
order_frameworkTitle: "הזמנה ממלאי מסגרת",
order_date: "תאריך ההזמנה",
order_number: "מספר דרישה / הזמנה",
order_supplier: "שם הספק",
order_supplierPhone: "טלפון הספק",
order_eta: "תאריך אספקה משוער",
order_qty: "כמות",
order_notEnoughFramework: "אין מספיק במלאי המסגרת",
doc_title_taken: "תיעוד: נלקח",
doc_title_returned: "תיעוד: הוחזר",
doc_title_repair: "תיעוד: נשלח לתיקון",
doc_title_repairReturn: "תיעוד: חזר מתיקון",
doc_title_received: "תיעוד: קבלת סחורה",
doc_qty: "כמות",
doc_date: "תאריך ושעה",
doc_reporter: "שם המדווח",
doc_photo: "צילום תמונה (חובה)",
doc_photo_camera: "צלם תמונה",
doc_photo_gallery: "בחר מהגלריה",
doc_photo_retake: "החלף תמונה",
doc_barcode: "סריקת ברקוד (לא חובה)",
doc_barcode_placeholder: "הזן / סרוק קוד",
doc_note: "הערה חופשית (לא חובה)",
doc_confirm: "אישור ועדכון מלאי",
doc_photoRequired: "יש לצלם או לבחור תמונה לפני האישור",
calendar_title: "לוח שנה",
calendar_noActivity: "אין פעולות מתועדות בתאריך זה",
summary_title: "סיכום מלאי",
summary_chooseCategories: "בחר קטגוריות להצגה",
summary_noItems: "אין פריטים להצגה",
settings_title: "הגדרות",
settings_language: "שפת תצוגה",
settings_site: "אתר נוכחי",
settings_user: "משתמש מחובר",
settings_logout: "החלפת משתמש / אתר",
settings_addLanguageNote: "ניתן להוסיף שפות נוספות (למשל הינדית ותאית) בהתאם לצוות",
sync_synced: "מסונכרן",
sync_syncing: "מסנכרן…",
sync_offline: "אופליין — יסתנכרן כשהחיבור יחזור",
back: "חזרה",
search_placeholder: "חיפוש כלי…",
delete: "מחיקה",
confirmDelete: "למחוק פריט זה?",
yes: "כן",
no: "לא",
all: "הכל",
close: "סגירה",
type_taken: "נלקח",
type_returned: "הוחזר",
type_repair: "תיקון",
type_repairReturn: "חזר מתיקון",
type_received: "התקבל",
type_order: "הוזמן",
type_frameworkOrder: "הזמנת מסגרת",
noProjectsYet: "עדיין אין פרויקטים — הוסיפו אחד כדי להתחיל",
required: "שדה חובה",
units: "יח'",
},
en: {
dir: "ltr",
appName: "Site Inventory",
login_title: "Sign in",
login_username: "Username",
login_role: "Role",
login_project: "Select project",
login_selectProject: "Choose a project…",
login_addProject: "Add new project",
login_language: "Language",
login_enter: "Enter",
login_needFields: "Please fill in username, role and select a project",
addProject_title: "New project",
addProject_name: "Site / project name",
addProject_address: "Address & location",
addProject_contact: "Logistics contact",
addProject_phone: "Contact phone",
save: "Save",
cancel: "Cancel",
siteDetails_title: "Site details",
menu_calendar: "Calendar",
menu_summary: "Inventory summary",
menu_settings: "Settings",
menu_addCategory: "Add category",
addCategory_title: "New category",
addCategory_name: "Category name",
addCategory_icon: "Choose an icon",
category_addItem: "Add equipment",
category_empty: "No items in this category yet. Add the first one.",
addItem_title: "New equipment",
addItem_name: "Item / tool name",
addItem_threshold: "Low-stock alert threshold (optional)",
item_found: "On site",
item_ordered: "Ordered",
item_framework: "Framework",
item_order: "Order",
item_frameworkOrder: "Framework order +",
item_taken: "Taken",
item_returned: "Returned",
item_repair: "Sent for repair",
item_received: "Goods received",
item_inRepair: "In repair",
item_returnFromRepair: "Returned from repair",
item_lowStock: "Low stock",
item_history: "Activity history",
item_noHistory: "No activity logged for this item yet.",
item_favorite: "Favorite",
order_title: "Order details",
order_frameworkTitle: "Order from framework stock",
order_date: "Order date",
order_number: "Requisition / order number",
order_supplier: "Supplier name",
order_supplierPhone: "Supplier phone",
order_eta: "Expected delivery date",
order_qty: "Quantity",
order_notEnoughFramework: "Not enough framework stock",
doc_title_taken: "Log: Taken",
doc_title_returned: "Log: Returned",
doc_title_repair: "Log: Sent for repair",
doc_title_repairReturn: "Log: Returned from repair",
doc_title_received: "Log: Goods received",
doc_qty: "Quantity",
doc_date: "Date & time",
doc_reporter: "Reported by",
doc_photo: "Photo (required)",
doc_photo_camera: "Take photo",
doc_photo_gallery: "Choose from gallery",
doc_photo_retake: "Replace photo",
doc_barcode: "Barcode scan (optional)",
doc_barcode_placeholder: "Enter / scan code",
doc_note: "Note (optional)",
doc_confirm: "Confirm & update stock",
doc_photoRequired: "Take or choose a photo before confirming",
calendar_title: "Calendar",
calendar_noActivity: "No activity logged for this date",
summary_title: "Inventory summary",
summary_chooseCategories: "Choose categories to show",
summary_noItems: "No items to show",
settings_title: "Settings",
settings_language: "Display language",
settings_site: "Current site",
settings_user: "Signed in as",
settings_logout: "Switch user / site",
settings_addLanguageNote: "More languages (e.g. Hindi, Thai) can be added for your crew",
sync_synced: "Synced",
sync_syncing: "Syncing…",
sync_offline: "Offline — will sync when back online",
back: "Back",
search_placeholder: "Search tools…",
delete: "Delete",
confirmDelete: "Delete this item?",
yes: "Yes",
no: "No",
all: "All",
close: "Close",
type_taken: "Taken",
type_returned: "Returned",
type_repair: "Repair",
type_repairReturn: "Returned from repair",
type_received: "Received",
type_order: "Ordered",
type_frameworkOrder: "Framework order",
noProjectsYet: "No projects yet — add one to get started",
required: "Required",
units: "u.",
},
};
const DEFAULT_CATEGORIES = [
{ id: "dailyWorkers", nameKey: "cat_dailyWorkers", name: { he: "עובדים יומיים", en: "Daily workers" }, icon: "Users" },
{ id: "heavyEquipment", name: { he: "כלי צמ\"ה", en: "Heavy equipment" }, icon: "Truck" },
{ id: "powerTools", name: { he: "כלים חשמליים", en: "Power tools" }, icon: "Zap" },
{ id: "technicalEquipment", name: { he: "ציוד טכני", en: "Technical equipment" }, icon: "Cpu" },
{ id: "safetyProducts", name: { he: "מוצרי בטיחות", en: "Safety products" }, icon: "ShieldCheck" },
{ id: "buildingMaterials", name: { he: "חומרי בניה", en: "Building materials" }, icon: "Boxes" },
{ id: "officeEquipment", name: { he: "ציוד משרדי", en: "Office equipment" }, icon: "Briefcase" },
{ id: "frameworkOrders", name: { he: "הזמנות מסגרת", en: "Framework orders" }, icon: "ClipboardList" },
];
const ICON_MAP = {
Users, Truck, Zap, Cpu, ShieldCheck, Boxes, Briefcase, ClipboardList,
Package, Wrench, Layers, FileText, ClipboardCheck,
};
const ICON_CHOICES = Object.keys(ICON_MAP);
/* ============================================================================
STORAGE HELPERS
All operational data (projects/categories/items/history) is shared=true so
every phone pointed at this app sees the same live data. The signed-in
user's own profile (name/role/last project/language) is stored personally
(shared=false) so it is remembered between visits for that account.
NOTE ON OFFLINE MODE: this environment does not allow browser storage
(localStorage/sessionStorage/IndexedDB) inside artifacts, and window.storage
itself is a network call. So true "written to disk, survives a closed tab
while offline" behaviour isn't available here. What this app does instead:
every change is applied to the screen immediately (optimistic UI) and a
background queue keeps retrying the network save every few seconds until
it succeeds — so a flaky connection or a few seconds of dead zone won't
lose anything, and the status pill tells you when you're not yet synced.
A fully offline-durable version of this app would need a native/PWA shell
with real local storage, which is outside what a web artifact can do.
============================================================================ */
async function storageGet(key, shared = true) {
try {
const res = await window.storage.get(key, shared);
return res && res.value != null ? JSON.parse(res.value) : null;
} catch (e) {
return null;
}
}
async function storageSetRaw(key, value, shared = true) {
const res = await window.storage.set(key, JSON.stringify(value), shared);
if (!res) throw new Error("storage set failed");
return res;
}
function uid() {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
/* ============================================================================
IMAGE COMPRESSION
============================================================================ */
function compressImage(file, maxSize = 360, quality = 0.55) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new window.Image();
img.onload = () => {
let { width, height } = img;
if (width > height && width > maxSize) {
height = Math.round((height * maxSize) / width);
width = maxSize;
} else if (height > maxSize) {
width = Math.round((width * maxSize) / height);
height = maxSize;
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, width, height);
resolve(canvas.toDataURL("image/jpeg", quality));
};
img.onerror = reject;
img.src = e.target.result;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
/* ============================================================================
SYNC ENGINE — a tiny context-free hook used at the App root
============================================================================ */
function useSyncEngine() {
const [status, setStatus] = useState("synced"); // synced | syncing | offline
const queueRef = useRef([]); // [{key, value, shared}]
const flushingRef = useRef(false);
const flush = useCallback(async () => {
if (flushingRef.current || queueRef.current.length === 0) return;
flushingRef.current = true;
setStatus("syncing");
while (queueRef.current.length > 0) {
const job = queueRef.current[0];
try {
await storageSetRaw(job.key, job.value, job.shared);
queueRef.current.shift();
} catch (e) {
setStatus("offline");
flushingRef.current = false;
return;
}
}
flushingRef.current = false;
setStatus("synced");
}, []);
useEffect(() => {
const id = setInterval(flush, 4000);
return () => clearInterval(id);
}, [flush]);
const persist = useCallback(
async (key, value, shared = true) => {
// de-dupe: replace any queued job for the same key
queueRef.current = queueRef.current.filter((j) => j.key !== key);
queueRef.current.push({ key, value, shared });
flush();
},
[flush]
);
return { status, persist };
}
/* ============================================================================
SMALL SHARED UI PRIMITIVES
============================================================================ */
function IconBtn({ onClick, children, style, title }) {
return (
);
}
function BigButton({ onClick, children, bg, color = "#fff", style }) {
return (
);
}
function Field({ label, children, required }) {
return (
);
}
const inputStyle = {
width: "100%", boxSizing: "border-box", padding: "12px 12px", fontSize: 16,
border: `2px solid ${TOKENS.line}`, borderRadius: TOKENS.radiusSm,
fontFamily: "'Barlow', sans-serif", background: "#fff", color: TOKENS.ink,
};
function Modal({ title, onClose, children, wide }) {
return (
e.stopPropagation()}
style={{
background: TOKENS.bgPanel, width: "100%", maxWidth: wide ? 560 : 480,
borderRadius: "16px 16px 0 0", maxHeight: "92vh", overflowY: "auto",
padding: "18px 18px 24px", boxShadow: "0 -6px 30px rgba(0,0,0,0.3)",
border: `2px solid ${TOKENS.lineStrong}`, borderBottom: "none",
}}
>
{title}
{children}
);
}
function Stepper({ value, onChange, min = 0 }) {
return (
onChange(Math.max(min, value - 1))}>
{value}
onChange(value + 1)}>
);
}
function SyncPill({ status, t }) {
const map = {
synced: { icon: , label: t.sync_synced, bg: TOKENS.returnedSoft, color: TOKENS.returned },
syncing: { icon: , label: t.sync_syncing, bg: TOKENS.blueSoft, color: TOKENS.blue },
offline: { icon: , label: t.sync_offline, bg: TOKENS.repairSoft, color: TOKENS.repair },
};
const s = map[status];
return (
{s.icon}
{s.label}
);
}
/* ============================================================================
PHOTO CAPTURE WIDGET
============================================================================ */
function PhotoCapture({ photo, setPhoto, t }) {
const camRef = useRef(null);
const galRef = useRef(null);
const handle = async (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
const dataUrl = await compressImage(file);
setPhoto(dataUrl);
e.target.value = "";
};
return (
);
}
const ghostBtnStyle = {
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
padding: "12px 10px", borderRadius: TOKENS.radiusSm, border: `2px solid ${TOKENS.lineStrong}`,
background: "#fff", fontWeight: 600, fontFamily: "'Barlow', sans-serif", fontSize: 14, cursor: "pointer",
};
/* ============================================================================
ROOT APP
============================================================================ */
export default function App() {
const [lang, setLang] = useState("he");
const t = TRANSLATIONS[lang];
const { status: syncStatus, persist } = useSyncEngine();
const [booted, setBooted] = useState(false);
const [profile, setProfile] = useState(null); // {username, role, projectId}
const [projects, setProjects] = useState([]);
const [categories, setCategories] = useState([]);
const [screen, setScreen] = useState("login"); // login|menu|category|item|calendar|summary|settings
const [activeCategory, setActiveCategory] = useState(null);
const [activeItemId, setActiveItemId] = useState(null);
const [items, setItems] = useState([]); // items of active category
const [showAddProject, setShowAddProject] = useState(false);
const [showSiteDetails, setShowSiteDetails] = useState(false);
const [showAddCategory, setShowAddCategory] = useState(false);
const [showAddItem, setShowAddItem] = useState(false);
// ---- boot: load profile + projects
useEffect(() => {
(async () => {
const [p, pr] = await Promise.all([
storageGet("profile", false),
storageGet("projects", true),
]);
setProjects(pr || []);
if (p) {
setProfile(p);
setLang(p.language || "he");
setScreen("menu");
}
setBooted(true);
})();
}, []);
// ---- load categories whenever project changes / screen enters menu
const loadCategories = useCallback(async (projectId) => {
if (!projectId) return;
const c = await storageGet(`categories:${projectId}`, true);
if (c && c.length) {
setCategories(c);
} else {
setCategories(DEFAULT_CATEGORIES);
persist(`categories:${projectId}`, DEFAULT_CATEGORIES, true);
}
}, [persist]);
useEffect(() => {
if (profile?.projectId) loadCategories(profile.projectId);
}, [profile?.projectId, loadCategories]);
// ---- load items whenever active category changes
const loadItems = useCallback(async (projectId, categoryId) => {
if (!projectId || !categoryId) return;
const it = await storageGet(`items:${projectId}:${categoryId}`, true);
setItems(it || []);
}, []);
useEffect(() => {
if (profile?.projectId && activeCategory) loadItems(profile.projectId, activeCategory.id);
}, [profile?.projectId, activeCategory, loadItems]);
const saveItems = useCallback(
(newItems) => {
setItems(newItems);
if (profile?.projectId && activeCategory) {
persist(`items:${profile.projectId}:${activeCategory.id}`, newItems, true);
}
},
[profile, activeCategory, persist]
);
const saveCategories = useCallback(
(newCats) => {
setCategories(newCats);
if (profile?.projectId) persist(`categories:${profile.projectId}`, newCats, true);
},
[profile, persist]
);
const saveProjects = useCallback(
(newProjects) => {
setProjects(newProjects);
persist("projects", newProjects, true);
},
[persist]
);
const appendActivity = useCallback(
async (entry) => {
if (!profile?.projectId) return;
const key = `activity:${profile.projectId}`;
const existing = (await storageGet(key, true)) || [];
const updated = [{ id: uid(), ...entry }, ...existing].slice(0, 300);
persist(key, updated, true);
},
[profile, persist]
);
const appendHistory = useCallback(
async (itemId, entry) => {
if (!profile?.projectId) return;
const key = `history:${profile.projectId}:${itemId}`;
const existing = (await storageGet(key, true)) || [];
const updated = [{ id: uid(), ...entry }, ...existing].slice(0, 40);
persist(key, updated, true);
return updated;
},
[profile, persist]
);
const handleLogin = (p) => {
const full = { ...p, language: lang };
setProfile(full);
persist("profile", full, false);
setScreen("menu");
};
const handleLogout = () => {
setProfile(null);
setScreen("login");
setActiveCategory(null);
setActiveItemId(null);
};
const setLanguage = (l) => {
setLang(l);
if (profile) {
const updated = { ...profile, language: l };
setProfile(updated);
persist("profile", updated, false);
}
};
const currentProject = projects.find((p) => p.id === profile?.projectId) || null;
const activeItem = items.find((i) => i.id === activeItemId) || null;
const dir = t.dir;
return (
{!booted ? (
…
) : screen === "login" ? (
setShowAddProject(true)}
/>
) : (
{
if (screen === "item") setScreen("category");
else if (screen === "category") setScreen("menu");
else setScreen("menu");
}}
onOpenSiteDetails={() => setShowSiteDetails(true)}
dir={dir}
/>
{screen === "menu" && (
{ setActiveCategory(c); setScreen("category"); }}
onAddCategory={() => setShowAddCategory(true)}
onCalendar={() => setScreen("calendar")}
onSummary={() => setScreen("summary")}
onSettings={() => setScreen("settings")}
/>
)}
{screen === "category" && activeCategory && (
{ setActiveItemId(it.id); setScreen("item"); }}
onAddItem={() => setShowAddItem(true)}
/>
)}
{screen === "item" && activeItem && (
saveItems(items.map((i) => (i.id === updated.id ? updated : i)))}
onDeleteItem={() => { saveItems(items.filter((i) => i.id !== activeItem.id)); setScreen("category"); }}
appendHistory={appendHistory}
appendActivity={(entry) => appendActivity({ ...entry, itemName: activeItem.name, categoryName: activeCategory.name[lang] })}
storageGet={storageGet}
/>
)}
{screen === "calendar" && (
)}
{screen === "summary" && (
)}
{screen === "settings" && (
)}
)}
{showAddProject && (
setShowAddProject(false)}
onSave={(proj) => {
const np = { id: uid(), ...proj };
saveProjects([...projects, np]);
setShowAddProject(false);
}}
/>
)}
{showSiteDetails && currentProject && (
setShowSiteDetails(false)} />
)}
{showAddCategory && (
setShowAddCategory(false)}
onSave={(cat) => {
saveCategories([...categories, { id: uid(), name: { he: cat.name, en: cat.name }, icon: cat.icon }]);
setShowAddCategory(false);
}}
/>
)}
{showAddItem && (
setShowAddItem(false)}
onSave={(item) => {
saveItems([
...items,
{
id: uid(),
name: item.name,
favorite: false,
lowStockThreshold: item.threshold || 0,
stock: { framework: 0, ordered: 0, found: 0, repair: 0 },
},
]);
setShowAddItem(false);
}}
/>
)}
);
}
/* ============================================================================
TOP BAR
============================================================================ */
function TopBar({ t, syncStatus, project, screen, onBack, onOpenSiteDetails, dir }) {
const BackIcon = dir === "rtl" ? ChevronRight : ChevronLeft;
return (
{screen !== "menu" ? (
) : (
)}
);
}
/* ============================================================================
LOGIN
============================================================================ */
function LoginScreen({ t, lang, setLang, projects, onLogin, onAddProject }) {
const [username, setUsername] = useState("");
const [role, setRole] = useState("");
const [projectId, setProjectId] = useState("");
const [error, setError] = useState("");
const submit = () => {
if (!username.trim() || !role.trim() || !projectId) {
setError(t.login_needFields);
return;
}
onLogin({ username: username.trim(), role: role.trim(), projectId });
};
return (
{t.login_title}
setUsername(e.target.value)} placeholder="" />
setRole(e.target.value)} placeholder="" />
{["he", "en"].map((l) => (
))}
{error && (
{error}
)}
{projects.length === 0 && (
{t.noProjectsYet}
)}
{t.login_enter}
);
}
/* ============================================================================
ADD PROJECT MODAL
============================================================================ */
function AddProjectModal({ t, onClose, onSave }) {
const [name, setName] = useState("");
const [address, setAddress] = useState("");
const [contact, setContact] = useState("");
const [phone, setPhone] = useState("");
return (
setName(e.target.value)} />
setAddress(e.target.value)} />
setContact(e.target.value)} />
setPhone(e.target.value)} type="tel" />
{t.cancel}
name.trim() && onSave({ name: name.trim(), address, contact, phone })}
bg={TOKENS.yellow} color={TOKENS.yellowInk}
>
{t.save}
);
}
/* ============================================================================
SITE DETAILS MODAL
============================================================================ */
function SiteDetailsModal({ t, project, onClose }) {
return (
);
}
function Row({ icon, label, accent }) {
return (
{icon}{label}
);
}
/* ============================================================================
MAIN MENU
============================================================================ */
function MainMenu({ t, lang, categories, onOpenCategory, onAddCategory, onCalendar, onSummary, onSettings }) {
return (
{categories.map((c) => {
const Icon = ICON_MAP[c.icon] || Package;
return (
);
})}
} label={t.menu_calendar} onClick={onCalendar} />
} label={t.menu_summary} onClick={onSummary} />
} label={t.menu_settings} onClick={onSettings} />
);
}
function MenuLink({ icon, label, onClick }) {
return (
);
}
/* ============================================================================
ADD CATEGORY MODAL
============================================================================ */
function AddCategoryModal({ t, onClose, onSave }) {
const [name, setName] = useState("");
const [icon, setIcon] = useState("Package");
return (
setName(e.target.value)} />
{ICON_CHOICES.map((k) => {
const Icon = ICON_MAP[k];
return (
);
})}
{t.cancel}
name.trim() && onSave({ name: name.trim(), icon })} bg={TOKENS.yellow} color={TOKENS.yellowInk}>
{t.save}
);
}
/* ============================================================================
CATEGORY SCREEN
============================================================================ */
function CategoryScreen({ t, lang, category, items, onOpenItem, onAddItem }) {
const [query, setQuery] = useState("");
const Icon = ICON_MAP[category.icon] || Package;
const filtered = items.filter((i) => i.name.toLowerCase().includes(query.toLowerCase()));
const sorted = [...filtered].sort((a, b) => (b.favorite ? 1 : 0) - (a.favorite ? 1 : 0));
return (
{category.name[lang] || category.name.he}
setQuery(e.target.value)}
/>
{sorted.length === 0 ? (
{t.category_empty}
) : (
{sorted.map((it) => {
const low = it.lowStockThreshold > 0 && it.stock.found <= it.lowStockThreshold;
return (
);
})}
)}
{t.category_addItem}
);
}
/* ============================================================================
ADD ITEM MODAL
============================================================================ */
function AddItemModal({ t, onClose, onSave }) {
const [name, setName] = useState("");
const [threshold, setThreshold] = useState("");
return (
setName(e.target.value)} />
setThreshold(e.target.value)} />
{t.cancel}
name.trim() && onSave({ name: name.trim(), threshold: Number(threshold) || 0 })}
bg={TOKENS.yellow} color={TOKENS.yellowInk}
>
{t.save}
);
}
/* ============================================================================
ITEM SCREEN
============================================================================ */
function ItemScreen({ t, lang, item, profile, onUpdateItem, onDeleteItem, appendHistory, appendActivity, storageGet }) {
const [actionType, setActionType] = useState(null); // taken|returned|repair|received
const [orderMode, setOrderMode] = useState(null); // 'order' | 'framework'
const [history, setHistory] = useState([]);
const [historyOpen, setHistoryOpen] = useState(false);
useEffect(() => {
if (!historyOpen || !profile?.projectId) return;
(async () => {
const h = await storageGet(`history:${profile.projectId}:${item.id}`, true);
setHistory(h || []);
})();
}, [historyOpen, profile, item.id, storageGet]);
const toggleFavorite = () => onUpdateItem({ ...item, favorite: !item.favorite });
const applyAction = async ({ type, qty, date, reporter, photo, barcode, note }) => {
const stock = { repair: 0, ...item.stock };
if (type === "taken") stock.found = Math.max(0, stock.found - qty);
if (type === "returned") stock.found = stock.found + qty;
if (type === "repair") {
stock.found = Math.max(0, stock.found - qty);
stock.repair = stock.repair + qty;
}
if (type === "repairReturn") {
stock.repair = Math.max(0, stock.repair - qty);
stock.found = stock.found + qty;
}
if (type === "received") {
stock.ordered = Math.max(0, stock.ordered - qty);
stock.found = stock.found + qty;
}
onUpdateItem({ ...item, stock });
await appendHistory(item.id, { type, qty, date, reporter, photo, barcode, note });
await appendActivity({ type, qty, date, reporter });
setActionType(null);
};
const applyOrder = async ({ mode, qty, orderDate, orderNumber, supplier, supplierPhone, eta }) => {
const stock = { ...item.stock };
if (mode === "framework") {
const take = Math.min(qty, stock.framework);
stock.framework = Math.max(0, stock.framework - take);
stock.ordered = stock.ordered + take;
} else {
stock.ordered = stock.ordered + qty;
}
onUpdateItem({ ...item, stock });
const entry = {
type: mode === "framework" ? "frameworkOrder" : "order",
qty, date: new Date().toISOString(), reporter: profile?.username,
orderDetails: { orderDate, orderNumber, supplier, supplierPhone, eta },
};
await appendHistory(item.id, entry);
await appendActivity({ type: entry.type, qty, date: entry.date, reporter: profile?.username });
setOrderMode(null);
};
return (
{/* Stock gauges */}
{t.item_found.toUpperCase()}
{item.stock.found}
{t.item_ordered}
{item.stock.ordered}
{t.item_framework}
{item.stock.framework}
{t.item_inRepair}
{item.stock.repair || 0}
{item.lowStockThreshold > 0 && item.stock.found <= item.lowStockThreshold && (
)}
{/* Order buttons */}
setOrderMode("order")} bg={TOKENS.blue}> {t.item_order}
setOrderMode("framework")} bg="#fff" color={TOKENS.blue} style={{ borderColor: TOKENS.blue }}>
{t.item_frameworkOrder}
{(item.stock.ordered > 0 || (item.stock.repair || 0) > 0) && (
{item.stock.ordered > 0 && (
setActionType("received")} bg="#fff" color={TOKENS.returned} style={{ borderColor: TOKENS.returned }}>
{t.item_received}
)}
{(item.stock.repair || 0) > 0 && (
setActionType("repairReturn")} bg="#fff" color={TOKENS.repair} style={{ borderColor: TOKENS.repair }}>
{t.item_returnFromRepair}
)}
)}
{/* Action buttons */}
setActionType("taken")} bg={TOKENS.taken} style={{ fontSize: 16 }}>{t.item_taken}
setActionType("returned")} bg={TOKENS.returned} style={{ fontSize: 16 }}>{t.item_returned}
setActionType("repair")} bg={TOKENS.repair} style={{ fontSize: 16 }}>{t.item_repair}
{historyOpen && (
{history.length === 0 ? (
{t.item_noHistory}
) : (
history.map((h) =>
)
)}
)}
{actionType && (
setActionType(null)}
onConfirm={(payload) => applyAction({ type: actionType, ...payload })}
/>
)}
{orderMode && (
setOrderMode(null)}
onConfirm={(payload) => applyOrder({ mode: orderMode, ...payload })}
/>
)}
);
}
const typeColor = {
taken: TOKENS.taken, returned: TOKENS.returned, repair: TOKENS.repair,
repairReturn: TOKENS.repair, received: TOKENS.returned, order: TOKENS.blue, frameworkOrder: TOKENS.blue,
};
function HistoryRow({ h, t, lang }) {
const d = new Date(h.date);
return (
{h.photo ? (

) : (
)}
{t[`type_${h.type}`] || h.type}
{h.qty ? × {h.qty} : null}
{d.toLocaleString(lang === "he" ? "he-IL" : "en-US")} · {h.reporter}
{h.note &&
{h.note}
}
{h.orderDetails?.supplier && (
{h.orderDetails.supplier} · {h.orderDetails.orderNumber}
)}
);
}
/* ============================================================================
ACTION (DOCUMENTATION) MODAL
============================================================================ */
function ActionModal({ t, type, profile, onClose, onConfirm }) {
const [qty, setQty] = useState(1);
const [photo, setPhoto] = useState(null);
const [barcode, setBarcode] = useState("");
const [note, setNote] = useState("");
const [error, setError] = useState("");
const now = new Date();
const titleKey = `doc_title_${type}`;
const color = typeColor[type];
const confirm = () => {
if (!photo) {
setError(t.doc_photoRequired);
return;
}
onConfirm({ qty, date: now.toISOString(), reporter: profile?.username, photo, barcode, note });
};
return (
{now.toLocaleString()}
{profile?.username}
{ setPhoto(p); setError(""); }} t={t} />
setBarcode(e.target.value)} />
{error && {error}
}
{t.doc_confirm}
);
}
/* ============================================================================
ORDER MODAL (regular + framework)
============================================================================ */
function OrderModal({ t, mode, maxFramework, onClose, onConfirm }) {
const [qty, setQty] = useState(1);
const [orderDate, setOrderDate] = useState(new Date().toISOString().slice(0, 10));
const [orderNumber, setOrderNumber] = useState("");
const [supplier, setSupplier] = useState("");
const [supplierPhone, setSupplierPhone] = useState("");
const [eta, setEta] = useState("");
const [error, setError] = useState("");
const confirm = () => {
if (mode === "framework" && qty > maxFramework) {
setError(t.order_notEnoughFramework);
return;
}
onConfirm({ qty, orderDate, orderNumber, supplier, supplierPhone, eta });
};
return (
{mode === "framework" && (
{t.item_framework}: {maxFramework}
)}
setOrderDate(e.target.value)} />
setOrderNumber(e.target.value)} />
setSupplier(e.target.value)} />
setSupplierPhone(e.target.value)} />
setEta(e.target.value)} />
{error && {error}
}
{t.save}
);
}
/* ============================================================================
CALENDAR SCREEN
============================================================================ */
function CalendarScreen({ t, lang, profile, storageGet }) {
const [activity, setActivity] = useState([]);
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().slice(0, 10));
useEffect(() => {
(async () => {
if (!profile?.projectId) return;
const a = await storageGet(`activity:${profile.projectId}`, true);
setActivity(a || []);
})();
}, [profile, storageGet]);
const dayEntries = activity.filter((a) => a.date && a.date.slice(0, 10) === selectedDate);
const markedDates = new Set(activity.map((a) => a.date && a.date.slice(0, 10)));
return (
{t.calendar_title}
setSelectedDate(e.target.value)}
/>
{dayEntries.length === 0 ? (
{t.calendar_noActivity}
) : (
{dayEntries.map((a) => (
{t[`type_${a.type}`] || a.type}
{a.itemName}
{a.categoryName} · {a.reporter} · {new Date(a.date).toLocaleTimeString(lang === "he" ? "he-IL" : "en-US")}
{a.qty ?
×{a.qty}
: null}
))}
)}
{markedDates.size} {lang === "he" ? "ימים עם פעילות בסה\"כ" : "days with logged activity in total"}
);
}
/* ============================================================================
SUMMARY SCREEN
============================================================================ */
function SummaryScreen({ t, lang, categories, profile, storageGet }) {
const [selected, setSelected] = useState(new Set(categories.map((c) => c.id)));
const [itemsByCat, setItemsByCat] = useState({});
useEffect(() => {
(async () => {
if (!profile?.projectId) return;
const entries = await Promise.all(
categories.map(async (c) => [c.id, (await storageGet(`items:${profile.projectId}:${c.id}`, true)) || []])
);
setItemsByCat(Object.fromEntries(entries));
})();
}, [categories, profile, storageGet]);
const toggle = (id) => {
const next = new Set(selected);
next.has(id) ? next.delete(id) : next.add(id);
setSelected(next);
};
const visibleCats = categories.filter((c) => selected.has(c.id));
return (
{t.summary_title}
{t.summary_chooseCategories}
{categories.map((c) => (
))}
{visibleCats.every((c) => (itemsByCat[c.id] || []).length === 0) && (
{t.summary_noItems}
)}
{visibleCats.map((c) => {
const its = itemsByCat[c.id] || [];
if (its.length === 0) return null;
return (
{c.name[lang] || c.name.he}
{its.map((it) => {
const low = it.lowStockThreshold > 0 && it.stock.found <= it.lowStockThreshold;
return (
);
})}
);
})}
);
}
function MiniStat({ label, val, color, dark }) {
return (
);
}
/* ============================================================================
SETTINGS SCREEN
============================================================================ */
function SettingsScreen({ t, lang, setLang, profile, project, onLogout }) {
return (
{t.settings_title}
{t.settings_language}
{["he", "en"].map((l) => (
))}
{t.settings_addLanguageNote}
} label={`${t.settings_user}: ${profile?.username} (${profile?.role})`} />
} label={`${t.settings_site}: ${project?.name || "—"}`} />
{t.settings_logout}
);
}